//
//  JerseyEncoding.swift
//  Swiften
//
//  Created by Cator Vee on 09/03/2017.
//  Copyright © 2017 Cator Vee. All rights reserved.
//

import Foundation
import Alamofire

public struct JerseyEncoding: ParameterEncoding {
	
	// MARK: Helper Types
	
	/// Defines whether the url-encoded query string is applied to the existing query string or HTTP body of the
	/// resulting URL request.
	///
	/// - methodDependent: Applies encoded query string result to existing query string for `GET`, `HEAD` and `DELETE`
	///                    requests and sets as the HTTP body for requests with any other HTTP method.
	/// - queryString:     Sets or appends encoded query string result to existing query string.
	/// - httpBody:        Sets encoded query string result as the HTTP body of the URL request.
	public enum Destination {
		case methodDependent, queryString, httpBody
	}
	
	// MARK: Properties
	
	/// Returns a default `JerseyEncoding` instance.
	public static var `default`: JerseyEncoding { return JerseyEncoding() }
	
	/// Returns a `JerseyEncoding` instance with a `.methodDependent` destination.
	public static var methodDependent: JerseyEncoding { return JerseyEncoding() }
	
	/// Returns a `JerseyEncoding` instance with a `.queryString` destination.
	public static var queryString: JerseyEncoding { return JerseyEncoding(destination: .queryString) }
	
	/// Returns a `JerseyEncoding` instance with an `.httpBody` destination.
	public static var httpBody: JerseyEncoding { return JerseyEncoding(destination: .httpBody) }
	
	/// The destination defining where the encoded query string is to be applied to the URL request.
	public let destination: Destination
	
	// MARK: Initialization
	
	/// Creates a `JerseyEncoding` instance using the specified destination.
	///
	/// - parameter destination: The destination defining where the encoded query string is to be applied.
	///
	/// - returns: The new `JerseyEncoding` instance.
	public init(destination: Destination = .methodDependent) {
		self.destination = destination
	}
	
	// MARK: Encoding
	
	/// Creates a URL request by encoding parameters and applying them onto an existing request.
	///
	/// - parameter urlRequest: The request to have parameters applied.
	/// - parameter parameters: The parameters to apply.
	///
	/// - throws: An `Error` if the encoding process encounters an error.
	///
	/// - returns: The encoded request.
	public func encode(_ urlRequest: URLRequestConvertible, with parameters: Parameters?) throws -> URLRequest {
		var urlRequest = try urlRequest.asURLRequest()
		
		guard let parameters = parameters else { return urlRequest }
		
		if let method = HTTPMethod(rawValue: urlRequest.httpMethod ?? "GET"), encodesParametersInURL(with: method) {
			guard let url = urlRequest.url else {
				throw AFError.parameterEncodingFailed(reason: .missingURL)
			}
			
			if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), !parameters.isEmpty {
				let percentEncodedQuery = (urlComponents.percentEncodedQuery.map { $0 + "&" } ?? "") + query(parameters)
				urlComponents.percentEncodedQuery = percentEncodedQuery
				urlRequest.url = urlComponents.url
			}
		} else {
			if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil {
				urlRequest.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
			}
			
			urlRequest.httpBody = query(parameters).data(using: .utf8, allowLossyConversion: false)
		}
		
		return urlRequest
	}
	
	private func serializingObject(_ object: Any) -> String {
		do {
			let data = try JSONSerialization.data(withJSONObject: object, options: [])
			if let string = String(data: data, encoding: .utf8) {
				return string
			}
		} catch {
			// nothing
		}
		return ""
	}
	
	/// Creates percent-escaped, URL encoded query string components from the given key-value pair using recursion.
	///
	/// - parameter key:   The key of the query component.
	/// - parameter value: The value of the query component.
	///
	/// - returns: The percent-escaped, URL encoded query string components.
	public func queryComponents(fromKey key: String, value: Any, level: Int = 0) -> [(String, String)] {
		var components: [(String, String)] = []
		
		let escapedKey = escape(key)
		if let dictionary = value as? [String: Any] {
			components.append((escapedKey, escape(serializingObject(dictionary))))
		} else if let array = value as? [Any] {
			if level == 0 {
				for value in array {
					components += queryComponents(fromKey: "\(key)", value: value, level: level + 1)
				}
			} else {
				components.append((escapedKey, escape(serializingObject(array))))
			}
		} else if let value = value as? NSNumber {
			if value.isBool {
				components.append((escapedKey, value.boolValue ? "true" : "false"))
			} else {
				components.append((escapedKey, escape("\(value)")))
			}
		} else if let bool = value as? Bool {
			components.append((escapedKey, bool ? "true" : "false"))
		} else {
			components.append((escapedKey, escape("\(value)")))
		}
		
		return components
	}
	
	/// Returns a percent-escaped string following RFC 3986 for a query string key or value.
	///
	/// RFC 3986 states that the following characters are "reserved" characters.
	///
	/// - General Delimiters: ":", "#", "[", "]", "@", "?", "/"
	/// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "="
	///
	/// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow
	/// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/"
	/// should be percent-escaped in the query string.
	///
	/// - parameter string: The string to be percent-escaped.
	///
	/// - returns: The percent-escaped string.
	public func escape(_ string: String) -> String {
		let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4
		let subDelimitersToEncode = "!$&'()*+,;="
		
		var allowedCharacterSet = CharacterSet.urlQueryAllowed
		allowedCharacterSet.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)")
		
		var escaped = ""
		
		//==========================================================================================================
		//
		//  Batching is required for escaping due to an internal bug in iOS 8.1 and 8.2. Encoding more than a few
		//  hundred Chinese characters causes various malloc error crashes. To avoid this issue until iOS 8 is no
		//  longer supported, batching MUST be used for encoding. This introduces roughly a 20% overhead. For more
		//  info, please refer to:
		//
		//      - https://github.com/Alamofire/Alamofire/issues/206
		//
		//==========================================================================================================
		
		if #available(iOS 8.3, *) {
			escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string
		} else {
			let batchSize = 50
			var index = string.startIndex
			
			while index != string.endIndex {
				let startIndex = index
				let endIndex = string.index(index, offsetBy: batchSize, limitedBy: string.endIndex) ?? string.endIndex
				let range = startIndex..<endIndex
				
				let substring = string.substring(with: range)
				
				escaped += substring.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? substring
				
				index = endIndex
			}
		}
		
		return escaped
	}
	
	private func query(_ parameters: [String: Any]) -> String {
		var components: [(String, String)] = []
		
		for key in parameters.keys.sorted(by: <) {
			let value = parameters[key]!
			components += queryComponents(fromKey: key, value: value)
		}
		
		return components.map { "\($0)=\($1)" }.joined(separator: "&")
	}
	
	private func encodesParametersInURL(with method: HTTPMethod) -> Bool {
		switch destination {
		case .queryString:
			return true
		case .httpBody:
			return false
		default:
			break
		}
		
		switch method {
		case .get, .head, .delete:
			return true
		default:
			return false
		}
	}
}

extension NSNumber {
	fileprivate var isBool: Bool { return CFBooleanGetTypeID() == CFGetTypeID(self) }
}
